map 与字符串
需要预先了解:
1. map 与字符串
map 是 Go 内置的键值容器,string 是 UTF-8 编码的只读字节序列。两者都是使用频率极高的类型,也各有几个容易踩坑的地方:nil map 不能写入、map 的遍历顺序是随机的、字符串的 len 返回的是字节数而不是字符数。
1.1 map 的基本操作
声明与初始化
var m map[string]int // 声明,零值是 nil,不能直接写入
m = make(map[string]int) // 用 make 初始化
m2 := map[string]int{"a": 1, "b": 2} // 字面量
m3 := make(map[string]int, 16) // 预分配容量(提示,不是硬上限)
增、删、改、查
m := map[string]int{"a": 1, "b": 2}
m["c"] = 3 // 增
m["a"] = 100 // 改
delete(m, "b") // 删
fmt.Println(m, len(m)) // map[a:100 c:3] 2
查询有两种形式。直接取值时,key 不存在返回该 value 类型的零值:
fmt.Println("不存在的 key:", m["zzz"]) // 0
想区分"值是零值"和"key 不存在",用 comma-ok 惯例:
v, ok := m["c"]
fmt.Println(v, ok) // 3 true
v, ok = m["zzz"]
fmt.Println(v, ok) // 0 false
ok 为 false 时,v 一定是零值。这个惯例在 Go 里到处出现(类型断言、channel 接收、字符串转换等),记住它的形态:第二个返回值永远是 bool,表示"是否成功/是否存在"。
nil map 可以读,不能写
var nilMap map[string]int
fmt.Println("nil map 读取:", nilMap["x"], len(nilMap)) // 0 0
for k, v := range nilMap { } // 安全,不执行
读取、len、range、delete 都不会 panic,但写入会直接 panic:
var nilMap map[string]int
nilMap["y"] = 1
panic: assignment to entry in nil map
注意:声明了 map 之后一定要
make或使用字面量初始化,否则第一次写入就会 panic。
1.2 key 的类型必须可比较
map 的 key 类型必须是可比较的(能用 == 判断相等)。可以作 key 的类型:
| 类型 | 说明 |
|---|---|
| 数值类型 | int、float64、complex128 等 |
string |
按字节序列比较 |
bool |
|
| 指针类型 | 比较地址是否相同 |
channel |
比较是否是同一个 channel |
| 接口类型 | 动态类型与动态值都可比较时才可比较 |
| 数组 | 元素类型可比较时 |
| 结构体 | 所有字段可比较时 |
不能作 key 的类型:切片、map、函数,以及包含这些字段的结构体。
m := map[[]int]int{} // 编译错误
invalid map key type []int
type Bad struct {
Data []int
}
m := map[Bad]int{} // 编译错误
invalid map key type Bad
浮点数的特例:NaN
float64 可以作为 key,但 NaN 是个例外:NaN 不等于任何值,包括它自己,所以用 NaN 作为 key 写入后,永远查不到它。
nan := math.NaN()
m := map[float64]int{nan: 1}
fmt.Println("len =", len(m)) // 1
fmt.Println("m[nan] =", m[nan]) // 0
v, ok := m[nan]
fmt.Println(v, ok) // 0 false
这个 key 确实存进去了(len 是 1),但无法被访问到,也无法用 delete 删除。实践中不要用浮点数做 key,需要精确匹配时用字符串或定点整数。
1.3 遍历顺序是随机的
Go 刻意让 map 的遍历顺序不可预测,每次 range 的起点都不同:
m := make(map[int]string)
for i := 0; i < 30; i++ {
m[i] = fmt.Sprintf("v%d", i)
}
for round := 1; round <= 4; round++ {
fmt.Printf("第 %d 次: ", round)
for k := range m {
fmt.Print(k, " ")
}
fmt.Println()
}
输出示例:
第 1 次: 12 21 25 27 8 19 29 2 9 24 0 5 14 17 22 28 6 13 23 3 15 16 26 4 10 11 18 20 1 7
第 2 次: 5 14 17 22 28 6 13 23 3 15 16 26 4 10 11 18 20 1 7 12 21 25 27 8 19 29 2 9 24 0
第 3 次: 10 11 18 20 1 7 12 21 25 27 8 19 29 2 9 24 0 5 14 17 22 28 6 13 23 3 15 16 26 4
注意每次的起点不同。这个设计是故意的:如果顺序固定,程序员就会依赖它,而 map 在扩容搬迁时顺序本来就会变。需要有序遍历时,先把 key 取出来排序:
keys := make([]int, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
slices.Sort 需要 Go 1.21+ 并导入 slices。
1.4 map 是引用语义
map 变量本身只是一个指针,赋值或传参时拷贝的是这个指针,两个变量操作的是同一个 map:
m3 := map[string]int{"a": 1}
m4 := m3
m4["b"] = 2
fmt.Println("m3 =", m3, "m4 =", m4) // m3 = map[a:1 b:2] m4 = map[a:1 b:2]
因此函数内对 map 的修改,调用方一定看得到,不需要传指针。
并发读写会直接 panic
多个 goroutine 同时对同一个 map 进行读写,运行时会检测到并终止程序:
m := map[int]int{}
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func(base int) {
defer wg.Done()
for j := 0; j < 100000; j++ {
m[base*100000+j] = j
}
}(i)
}
wg.Wait()
fatal error: concurrent map writes
注意这是 fatal error 而不是可以被 recover 捕获的 panic。解决方案是加锁或用 sync.Map,具体选型见 sync 包与原子操作。
1.5 不能对 map 的 value 取地址
m := map[string]int{"a": 1}
p := &m["a"] // 编译错误
invalid operation: cannot take address of m["a"] (map index expression of type int)
原因是 map 在扩容搬迁时元素会被移动到新的内存位置,如果允许取地址,这个地址随时可能失效。所以下面这种写法也编译不过:
m["a"].Field = 1 // 编译错误
想修改结构体 value 的字段,必须整体替换:
type User struct{ Name string }
m := map[string]User{"u1": {"一撮灰"}}
u := m["u1"] // 取出来的是副本
u.Name = "灰灰"
m["u1"] = u // 写回去
或者把 value 类型改成指针 map[string]*User,这样取出来的是指针,可以直接改字段。
1.6 字符串的本质
string 的底层是一个只读的字节序列,可以理解成"指向字节数组的指针 + 长度"。它的关键性质是不可变:一旦创建,内容就不能修改。
s := "hello"
s[0] = 'H' // 编译错误
cannot assign to s[0] (neither addressable nor a map index expression)
需要修改时,先转成 []byte 或 []rune:
bs := []byte(s)
bs[0] = 'H'
fmt.Println(string(bs)) // Hello
fmt.Println(s) // hello,原字符串不变
这个转换会复制一份数据,因此在热点路径上频繁转换会有性能开销(零拷贝的做法见 反射与 unsafe)。
1.7 UTF-8 与 rune
字符串按 UTF-8 编码存储,一个字符可能占多个字节。这带来三个必须记住的结论:
1. len(s) 返回字节数。
s := "Go语言"
fmt.Println("len:", len(s)) // 8
fmt.Println("rune 数量:", utf8.RuneCountInString(s)) // 4
"Go语言" 有 4 个字符,但 G、o 各占 1 字节,两个汉字各占 3 字节,共 8 字节。
2. 下标访问得到的是 byte。
fmt.Println("s[0]:", s[0], "s[2]:", s[2]) // s[0]: 71 s[2]: 232
232 是汉字"语"的第一个字节,单独拿出来没有意义。用 s[2] 想取"第二个字符"是错的。
3. for range 按 rune 遍历,下标是字节偏移。
for i, r := range s {
fmt.Printf("下标 %d: %q (%d 字节)\n", i, r, utf8.RuneLen(r))
}
下标 0: 'G' (1 字节)
下标 1: 'o' (1 字节)
下标 2: '语' (3 字节)
下标 5: '言' (3 字节)
注意下标是 0、1、2、5——因为"语"占了 2、3、4 三个字节。
需要按"第几个字符"来操作时,转成 []rune:
runes := []rune(s)
fmt.Println("第 3 个 rune:", string(runes[2])) // 语
注意:
utf8.RuneCountInString需要导入unicode/utf8。rune是int32的别名,表示一个 Unicode 码点;byte是uint8的别名,表示一个字节。详见 变量、常量与基本类型。
1.8 strings 与 strconv
常用字符串操作
s := "Go语言"
strings.Contains(s, "语言") // true
strings.Split("a,b,c", ",") // [a b c]
strings.Join([]string{"a", "b", "c"}, "-") // a-b-c
strings.Repeat("ab", 3) // ababab
strings.TrimSpace(" hi ") // hi
strings.HasPrefix(s, "Go") // true
strings.HasSuffix(s, "语言") // true
strings.Index(s, "语") // 2,返回字节下标
这些函数都不会修改原字符串,而是返回新的字符串。
用 strings.Builder 拼接
循环里用 + 拼接字符串会反复分配内存(每次都要复制已有内容),strings.Builder 通过内部缓冲区分摊开销:
var b strings.Builder
for i := 0; i < 3; i++ {
b.WriteString(strconv.Itoa(i))
b.WriteByte('-')
}
b.WriteString("end")
fmt.Println(b.String(), "len =", b.Len()) // 0-1-2-end len = 9
Builder 的零值就可以直接使用,不需要初始化。它的内部机制见 切片、map 与字符串的底层实现。
strconv
strconv 负责字符串与基本类型之间的转换:
strconv.Itoa(42) // "42",int 转 string
strconv.Atoi("42") // 42, nil,string 转 int
strconv.FormatFloat(3.14159, 'f', 2, 64) // "3.14"
strconv.Quote("你好") // "\"你好\""
Atoi 失败时返回零值加错误,不会 panic:
n, err := strconv.Atoi("abc")
fmt.Println(n, err) // 0 strconv.Atoi: parsing "abc": invalid syntax
1.9 练习
- 统计一段中文字符串里每个字符出现的次数,用
map[rune]int实现,然后按字符排序输出。 - 写一个函数
WordCount(s string) map[string]int统计单词出现次数,思考如何让输出顺序稳定。 - 用两种方式拼接 10000 次字符串(
+与strings.Builder),用testing.Benchmark或简单的计时对比耗时。



